database.tsx 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. import { PermissionAction } from '@supabase/shared-types/out/constants'
  2. import { useQueryClient } from '@tanstack/react-query'
  3. import { useFlag, useParams } from 'common'
  4. import dayjs from 'dayjs'
  5. import { ArrowRight, ExternalLink, RefreshCw } from 'lucide-react'
  6. import Link from 'next/link'
  7. import { useEffect, useRef, useState } from 'react'
  8. import { toast } from 'sonner'
  9. import { Alert, AlertDescription, Button } from 'ui'
  10. import ReportHeader from '@/components/interfaces/Reports/ReportHeader'
  11. import ReportPadding from '@/components/interfaces/Reports/ReportPadding'
  12. import { REPORT_DATERANGE_HELPER_LABELS } from '@/components/interfaces/Reports/Reports.constants'
  13. import ReportStickyNav from '@/components/interfaces/Reports/ReportStickyNav'
  14. import ReportWidget from '@/components/interfaces/Reports/ReportWidget'
  15. import { ReportChartUpsell } from '@/components/interfaces/Reports/v2/ReportChartUpsell'
  16. import { POOLING_OPTIMIZATIONS } from '@/components/interfaces/Settings/Database/ConnectionPooling/ConnectionPooling.constants'
  17. import DiskSizeConfigurationModal from '@/components/interfaces/Settings/Database/DiskSizeConfigurationModal'
  18. import { LogsDatePicker } from '@/components/interfaces/Settings/Logs/Logs.DatePickers'
  19. import UpgradePrompt from '@/components/interfaces/Settings/Logs/UpgradePrompt'
  20. import DefaultLayout from '@/components/layouts/DefaultLayout'
  21. import ObservabilityLayout from '@/components/layouts/ObservabilityLayout/ObservabilityLayout'
  22. import Table from '@/components/to-be-cleaned/Table'
  23. import { ButtonTooltip } from '@/components/ui/ButtonTooltip'
  24. import type { MultiAttribute } from '@/components/ui/Charts/ComposedChart.utils'
  25. import { LazyComposedChartHandler } from '@/components/ui/Charts/ComposedChartHandler'
  26. import { ReportSettings } from '@/components/ui/Charts/ReportSettings'
  27. import { ObservabilityLink } from '@/components/ui/ObservabilityLink'
  28. import { analyticsKeys } from '@/data/analytics/keys'
  29. import { useDiskAttributesQuery } from '@/data/config/disk-attributes-query'
  30. import { useProjectDiskResizeMutation } from '@/data/config/project-disk-resize-mutation'
  31. import { useDatabaseSizeQuery } from '@/data/database/database-size-query'
  32. import { useMaxConnectionsQuery } from '@/data/database/max-connections-query'
  33. import { usePgbouncerConfigQuery } from '@/data/database/pgbouncer-config-query'
  34. import { getReportAttributesV2 } from '@/data/reports/database-charts'
  35. import { useDatabaseReport } from '@/data/reports/database-report-query'
  36. import { useProjectAddonsQuery } from '@/data/subscriptions/project-addons-query'
  37. import { useCheckEntitlements } from '@/hooks/misc/useCheckEntitlements'
  38. import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
  39. import { useRefreshHandler, useReportDateRange } from '@/hooks/misc/useReportDateRange'
  40. import { useSelectedOrganizationQuery } from '@/hooks/misc/useSelectedOrganization'
  41. import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
  42. import { DOCS_URL } from '@/lib/constants'
  43. import { formatBytes } from '@/lib/helpers'
  44. import { useDatabaseSelectorStateSnapshot } from '@/state/database-selector'
  45. import type { NextPageWithLayout } from '@/types'
  46. const DatabaseReport: NextPageWithLayout = () => {
  47. return (
  48. <ReportPadding>
  49. <DatabaseUsage />
  50. </ReportPadding>
  51. )
  52. }
  53. DatabaseReport.getLayout = (page) => (
  54. <DefaultLayout>
  55. <ObservabilityLayout title="Database">{page}</ObservabilityLayout>
  56. </DefaultLayout>
  57. )
  58. export type UpdateDateRange = (from: string, to: string) => void
  59. export default DatabaseReport
  60. const DatabaseUsage = () => {
  61. const { db, chart, ref } = useParams()
  62. const { data: project } = useSelectedProjectQuery()
  63. const { data: org } = useSelectedOrganizationQuery()
  64. const {
  65. selectedDateRange,
  66. updateDateRange,
  67. datePickerValue,
  68. datePickerHelpers,
  69. showUpgradePrompt,
  70. setShowUpgradePrompt,
  71. handleDatePickerChange,
  72. } = useReportDateRange(REPORT_DATERANGE_HELPER_LABELS.LAST_60_MINUTES)
  73. const state = useDatabaseSelectorStateSnapshot()
  74. const queryClient = useQueryClient()
  75. const [isRefreshing, setIsRefreshing] = useState(false)
  76. const [showIncreaseDiskSizeModal, setshowIncreaseDiskSizeModal] = useState(false)
  77. const isReplicaSelected = state.selectedDatabaseId !== project?.ref
  78. const report = useDatabaseReport()
  79. const { data, params, largeObjectsSql, isPending: isLoading, refresh } = report
  80. const { data: databaseSizeData } = useDatabaseSizeQuery({
  81. projectRef: project?.ref,
  82. connectionString: project?.connectionString || undefined,
  83. })
  84. const databaseSizeBytes = databaseSizeData ?? 0
  85. const currentDiskSize = project?.volumeSizeGb ?? 0
  86. const { data: diskConfig } = useDiskAttributesQuery({ projectRef: project?.ref })
  87. const { data: maxConnections } = useMaxConnectionsQuery({
  88. projectRef: project?.ref,
  89. connectionString: project?.connectionString,
  90. })
  91. usePgbouncerConfigQuery({ projectRef: project?.ref })
  92. // PGBouncer connections
  93. const { data: addons } = useProjectAddonsQuery({ projectRef: project?.ref })
  94. const computeInstance = addons?.selected_addons.find((addon) => addon.type === 'compute_instance')
  95. const poolingOptimizations =
  96. POOLING_OPTIMIZATIONS[
  97. (computeInstance?.variant.identifier as keyof typeof POOLING_OPTIMIZATIONS) ??
  98. (project?.infra_compute_size === 'nano' ? 'ci_nano' : 'ci_micro')
  99. ]
  100. const defaultMaxClientConn = poolingOptimizations.maxClientConn ?? 200
  101. const { can: canUpdateDiskSizeConfig } = useAsyncCheckPermissions(
  102. PermissionAction.UPDATE,
  103. 'projects',
  104. {
  105. resource: {
  106. project_id: project?.id,
  107. },
  108. }
  109. )
  110. const { getEntitlementSetValues, isLoading: isEntitlementLoading } = useCheckEntitlements(
  111. 'observability.dashboard_advanced_metrics'
  112. )
  113. const entitledFeatures = getEntitlementSetValues()
  114. const isSpendCapEnabled =
  115. entitledFeatures.includes('database') &&
  116. !org?.usage_billing_enabled &&
  117. project?.cloud_provider !== 'FLY'
  118. const showDiskIOBurstBalanceChart = useFlag('showDiskIOBurstBalanceChart')
  119. const REPORT_ATTRIBUTES = getReportAttributesV2(
  120. entitledFeatures,
  121. project!,
  122. diskConfig,
  123. maxConnections,
  124. defaultMaxClientConn,
  125. isSpendCapEnabled,
  126. showDiskIOBurstBalanceChart
  127. )
  128. const { isPending: isUpdatingDiskSize } = useProjectDiskResizeMutation({
  129. onSuccess: (_, variables) => {
  130. toast.success(`Successfully updated disk size to ${variables.volumeSize} GB`)
  131. setshowIncreaseDiskSizeModal(false)
  132. },
  133. })
  134. const onRefreshReport = useRefreshHandler(
  135. datePickerValue,
  136. datePickerHelpers,
  137. handleDatePickerChange,
  138. async () => {
  139. if (!selectedDateRange) return
  140. setIsRefreshing(true)
  141. refresh()
  142. const { period_start, period_end, interval } = selectedDateRange
  143. REPORT_ATTRIBUTES.flatMap((chart) => chart.attributes || [])
  144. .filter((attr): attr is MultiAttribute => attr !== false)
  145. .forEach((attr) => {
  146. queryClient.invalidateQueries({
  147. queryKey: analyticsKeys.infraMonitoring(ref, {
  148. attribute: attr.attribute,
  149. startDate: period_start.date,
  150. endDate: period_end.date,
  151. interval,
  152. databaseIdentifier: state.selectedDatabaseId,
  153. }),
  154. })
  155. })
  156. if (isReplicaSelected) {
  157. queryClient.invalidateQueries({
  158. queryKey: analyticsKeys.infraMonitoring(ref, {
  159. attribute: 'physical_replication_lag_physical_replication_lag_seconds',
  160. startDate: period_start.date,
  161. endDate: period_end.date,
  162. interval,
  163. databaseIdentifier: state.selectedDatabaseId,
  164. }),
  165. })
  166. }
  167. setTimeout(() => setIsRefreshing(false), 1000)
  168. }
  169. )
  170. const stateSyncedFromUrlRef = useRef(false)
  171. useEffect(() => {
  172. if (stateSyncedFromUrlRef.current) return
  173. stateSyncedFromUrlRef.current = true
  174. if (db !== undefined) {
  175. setTimeout(() => {
  176. // [Joshen] Adding a timeout here to support navigation from settings to reports
  177. // Both are rendering different instances of ProjectLayout which is where the
  178. // DatabaseSelectorContextProvider lies in (unless we reckon shifting the provider up one more level is better)
  179. state.setSelectedDatabaseId(db)
  180. }, 100)
  181. }
  182. if (chart !== undefined) {
  183. setTimeout(() => {
  184. const el = document.getElementById(chart)
  185. if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
  186. }, 200)
  187. }
  188. }, [db, chart, state])
  189. return (
  190. <>
  191. <ReportHeader showDatabaseSelector title="Database" />
  192. <ReportStickyNav
  193. content={
  194. <>
  195. <ButtonTooltip
  196. type="default"
  197. disabled={isRefreshing}
  198. icon={<RefreshCw className={isRefreshing ? 'animate-spin' : ''} />}
  199. className="w-7"
  200. tooltip={{ content: { side: 'bottom', text: 'Refresh report' } }}
  201. onClick={onRefreshReport}
  202. />
  203. <ReportSettings chartId="database-charts" />
  204. <div className="flex items-center gap-3">
  205. <LogsDatePicker
  206. onSubmit={handleDatePickerChange}
  207. value={datePickerValue}
  208. helpers={datePickerHelpers}
  209. />
  210. <UpgradePrompt
  211. show={showUpgradePrompt}
  212. setShowUpgradePrompt={setShowUpgradePrompt}
  213. title="Report date range"
  214. description="Report data can be stored for a maximum of 3 months depending on the plan that your project is on."
  215. source="databaseReportDateRange"
  216. />
  217. {selectedDateRange && (
  218. <div className="flex items-center gap-x-2 text-xs">
  219. <p className="text-foreground-light">
  220. {dayjs(selectedDateRange.period_start.date).format('MMM D, h:mma')}
  221. </p>
  222. <p className="text-foreground-light">
  223. <ArrowRight size={12} />
  224. </p>
  225. <p className="text-foreground-light">
  226. {dayjs(selectedDateRange.period_end.date).format('MMM D, h:mma')}
  227. </p>
  228. </div>
  229. )}
  230. </div>
  231. </>
  232. }
  233. >
  234. {selectedDateRange &&
  235. REPORT_ATTRIBUTES.filter((chart) => !chart.hide).map((chart) => {
  236. const chartAvailable =
  237. !chart.entitlement ||
  238. isEntitlementLoading ||
  239. entitledFeatures.includes(chart.entitlement)
  240. return chartAvailable ? (
  241. <LazyComposedChartHandler
  242. key={chart.id}
  243. {...chart}
  244. attributes={chart.attributes as MultiAttribute[]}
  245. interval={selectedDateRange.interval}
  246. startDate={selectedDateRange?.period_start?.date}
  247. endDate={selectedDateRange?.period_end?.date}
  248. updateDateRange={updateDateRange}
  249. defaultChartStyle={chart.defaultChartStyle as 'line' | 'bar' | 'stackedAreaLine'}
  250. syncId="database-charts"
  251. showMaxValue={
  252. chart.id === 'client-connections' ||
  253. chart.id === 'client-connections-basic' ||
  254. chart.id === 'pgbouncer-connections'
  255. ? true
  256. : chart.showMaxValue
  257. }
  258. />
  259. ) : (
  260. <ReportChartUpsell
  261. key={chart.id}
  262. report={{ label: chart.label, requiredPlan: chart.requiredPlan }}
  263. orgSlug={org?.slug ?? ''}
  264. />
  265. )
  266. })}
  267. {selectedDateRange && isReplicaSelected && (
  268. <LazyComposedChartHandler
  269. id="replication-lag"
  270. label="Replication lag"
  271. format="s"
  272. valuePrecision={2}
  273. showTooltip
  274. YAxisProps={{
  275. width: 50,
  276. tickFormatter: (value: number) => `${value}s`,
  277. }}
  278. attributes={[
  279. {
  280. attribute: 'physical_replication_lag_physical_replication_lag_seconds',
  281. provider: 'infra-monitoring',
  282. label: 'Replication lag',
  283. tooltip:
  284. 'Seconds the read replica is behind its primary. Sustained or growing lag may indicate the replica cannot keep up with write throughput',
  285. },
  286. ]}
  287. interval={selectedDateRange.interval}
  288. startDate={selectedDateRange?.period_start?.date}
  289. endDate={selectedDateRange?.period_end?.date}
  290. updateDateRange={updateDateRange}
  291. defaultChartStyle="line"
  292. syncId="database-charts"
  293. />
  294. )}
  295. </ReportStickyNav>
  296. <section id="database-size-report">
  297. <ReportWidget
  298. isLoading={isLoading}
  299. params={params.largeObjects}
  300. title="Database Size"
  301. data={data.largeObjects || []}
  302. queryType={'db'}
  303. resolvedSql={largeObjectsSql}
  304. renderer={(props) => {
  305. return (
  306. <div>
  307. <div className="col-span-4 inline-grid grid-cols-12 gap-12 w-full mt-5">
  308. <div className="grid gap-2 col-span-4 xl:col-span-2">
  309. <h5>Space used</h5>
  310. <span className="text-lg">{formatBytes(databaseSizeBytes, 2, 'GB')}</span>
  311. </div>
  312. <div className="grid gap-2 col-span-4 xl:col-span-3">
  313. <h5>Provisioned disk size</h5>
  314. <span className="text-lg">{currentDiskSize} GB</span>
  315. </div>
  316. <div className="col-span-full lg:col-span-4 xl:col-span-7 lg:text-right">
  317. {project?.cloud_provider === 'AWS' ? (
  318. <Button asChild type="default">
  319. <Link href={`/project/${ref}/settings/compute-and-disk`}>
  320. Increase disk size
  321. </Link>
  322. </Button>
  323. ) : (
  324. <ButtonTooltip
  325. type="default"
  326. disabled={!canUpdateDiskSizeConfig}
  327. onClick={() => setshowIncreaseDiskSizeModal(true)}
  328. tooltip={{
  329. content: {
  330. side: 'bottom',
  331. text: !canUpdateDiskSizeConfig
  332. ? 'You need additional permissions to increase the disk size'
  333. : undefined,
  334. },
  335. }}
  336. >
  337. Increase disk size
  338. </ButtonTooltip>
  339. )}
  340. </div>
  341. </div>
  342. <h3 className="mt-8 text-sm">Large Objects</h3>
  343. {!props.isLoading && props.data.length === 0 && <span>No large objects found</span>}
  344. {!props.isLoading && props.data.length > 0 && (
  345. <Table
  346. className="space-y-3 mt-4"
  347. head={[
  348. <Table.th key="object" className="py-2">
  349. Object
  350. </Table.th>,
  351. <Table.th key="size" className="py-2">
  352. Size
  353. </Table.th>,
  354. ]}
  355. body={props.data?.map((object) => {
  356. const percentage = (
  357. ((object.table_size as number) / databaseSizeBytes) *
  358. 100
  359. ).toFixed(2)
  360. return (
  361. <Table.tr key={`${object.schema_name}.${object.relname}`}>
  362. <Table.td>
  363. {object.schema_name}.{object.relname}
  364. </Table.td>
  365. <Table.td>
  366. {formatBytes(object.table_size)} ({percentage}%)
  367. </Table.td>
  368. </Table.tr>
  369. )
  370. })}
  371. />
  372. )}
  373. </div>
  374. )
  375. }}
  376. append={() => (
  377. <div className="px-6 pb-6">
  378. <Alert variant="default" className="mt-4">
  379. <AlertDescription>
  380. <div className="space-y-2">
  381. <p>
  382. New Briven projects have a database size of ~40-60mb. This space includes
  383. pre-installed extensions, schemas, and default Postgres data. Additional
  384. database size is used when installing extensions, even if those extensions are
  385. inactive.
  386. </p>
  387. <Button asChild type="default" icon={<ExternalLink />}>
  388. <Link
  389. href={`${DOCS_URL}/guides/platform/database-size#disk-space-usage`}
  390. target="_blank"
  391. rel="noreferrer"
  392. >
  393. Read about database size
  394. </Link>
  395. </Button>
  396. </div>
  397. </AlertDescription>
  398. </Alert>
  399. </div>
  400. )}
  401. />
  402. <DiskSizeConfigurationModal
  403. visible={showIncreaseDiskSizeModal}
  404. loading={isUpdatingDiskSize}
  405. hideModal={setshowIncreaseDiskSizeModal}
  406. />
  407. </section>
  408. <div className="py-8">
  409. <ObservabilityLink />
  410. </div>
  411. </>
  412. )
  413. }